# Copyright 2015 Google Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Merges two android manifest xml files."""

import re
import sys
import xml.dom.minidom

from tools.android import android_permissions
from third_party.py import gflags

FLAGS = gflags.FLAGS
EXCLUDE_ALL_ARG = 'all'

gflags.DEFINE_multistring(
    'exclude_permission', None,
    'Permissions to be excluded, e.g.: "android.permission.READ_LOGS".'
    'This is a multistring, so multiple of those flags can be provided.'
    'Pass "%s" to exclude all permissions contributed by mergees.'
    % EXCLUDE_ALL_ARG)
gflags.DEFINE_multistring(
    'mergee', None,
    'Mergee manifest that will be merged to merger manifest.'
    'This is a multistring, so multiple of those flags can be provided.')
gflags.DEFINE_string('merger', None,
                     'Merger AndroidManifest file to be merged.')
gflags.DEFINE_string('output', None, 'Output file with merged manifests.')

USAGE = """Error, invalid arguments.
Usage: merge_manifests.py --merger=<merger> --mergee=<mergee1> --mergee=<merge2>
    --exclude_permission=[Exclude permissions from mergee] --output=<output>
Examples:
  merge_manifests.py --merger=manifest.xml --mergee=manifest2.xml
    --mergee=manifest3.xml --exclude_permission=android.permission.READ_LOGS
    --output=AndroidManifest.xml

    merge_manifests.py --merger=manifest.xml --mergee=manifest2.xml
    --mergee=manifest3.xml --exclude_permission=%s
    --output=AndroidManifest.xml
""" % EXCLUDE_ALL_ARG


class UndefinedPlaceholderException(Exception):
  """Exception thrown when encountering a placeholder without a replacement.
  """
  pass


class MalformedManifestException(Exception):
  """Exception thrown when encountering a fatally malformed manifest.
  """
  pass


class MergeManifests(object):
  """A utility class for merging two android manifest.xml files.

  This is useful when including another app as android library.
  """
  _ACTIVITY = 'activity'
  _ANDROID_NAME = 'android:name'
  _ANDROID_LABEL = 'android:label'
  _INTENT_FILTER = 'intent-filter'
  _MANIFEST = 'manifest'
  _USES_PERMISSION = 'uses-permission'
  _NODES_TO_COPY_FROM_MERGEE = {
      _MANIFEST: [
          'instrumentation',
          'permission',
          _USES_PERMISSION,
          'uses-feature',
          'permission-group',
          ],
      'application': [
          'activity',
          'activity-alias',
          'provider',
          'receiver',
          'service',
          'uses-library',
          'meta-data',
          ],
  }
  _NODES_TO_REMOVE_FROM_MERGER = []
  _PACKAGE = 'package'

  def __init__(self, merger, mergees, exclude_permissions=None):
    """Constructs and initializes the MergeManifests object.

    Args:
      merger: First (merger) AndroidManifest.xml string.
      mergees: mergee AndroidManifest.xml strings, a list.
      exclude_permissions: Permissions to be excludeed from merging,
        string list. "all" means don't include any permissions.
    """
    self._merger = merger
    self._mergees = mergees
    self._exclude_permissions = exclude_permissions
    self._merger_dom = xml.dom.minidom.parseString(self._merger[0])

  def _ApplyExcludePermissions(self, dom):
    """Apply exclude filters.

    Args:
      dom: Document dom object from which to exclude permissions.
    """
    if self._exclude_permissions:
      exclude_all_permissions = EXCLUDE_ALL_ARG in self._exclude_permissions
      for element in dom.getElementsByTagName(self._USES_PERMISSION):
        if element.hasAttribute(self._ANDROID_NAME):
          attrib = element.getAttribute(self._ANDROID_NAME)
          if exclude_all_permissions or attrib in self._exclude_permissions:
            element.parentNode.removeChild(element)

  def _ExpandPackageName(self, node):
    """Set the package name if it is in a short form.

    Filtering logic for what elements have package expansion:
    If the name starts with a dot, always prefix it with the package.
    If the name has a dot anywhere else, do not prefix it.
    If the name has no dot at all, also prefix it with the package.

    The massageManifest function shows where this rule is applied:

    In the application element, on the name and backupAgent attributes.
    In the activity, service, receiver, provider, and activity-alias elements,
    on the name attribute.
    In the activity-alias element, on the targetActivity attribute.

    Args:
      node: Xml Node for which to expand package name.
    """
    package_name = node.getElementsByTagName(self._MANIFEST).item(
        0).getAttribute(self._PACKAGE)

    if not package_name:
      return

    for element in node.getElementsByTagName('*'):
      if element.nodeName not in [
          'activity',
          'activity-alias',
          'application',
          'service',
          'receiver',
          'provider',
          ]:
        continue

      self._ExpandPackageNameHelper(package_name, element, self._ANDROID_NAME)

      if element.nodeName == 'activity':
        self._ExpandPackageNameHelper(package_name, element,
                                      'android:parentActivityName')

      if element.nodeName == 'activity-alias':
        self._ExpandPackageNameHelper(package_name, element,
                                      'android:targetActivity')
        continue

      if element.nodeName == 'application':
        self._ExpandPackageNameHelper(package_name, element,
                                      'android:backupAgent')

  def _ExpandPackageNameHelper(self, package_name, element, attribute_name):
    if element.hasAttribute(attribute_name):
      class_name = element.getAttribute(attribute_name)

      if class_name.startswith('.'):
        pass
      elif '.' not in class_name:
        class_name = '.' + class_name
      else:
        return

      element.setAttribute(attribute_name, package_name + class_name)

  def _RemoveFromMerger(self):
    """Remove from merger."""
    for tag_name in self._NODES_TO_REMOVE_FROM_MERGER:
      elements = self._merger_dom.getElementsByTagName(tag_name)
      for element in elements:
        element.parentNode.removeChild(element)

  def _RemoveAndroidLabel(self, node):
    """Remove android:label.

    We do this because it is not required by merger manifest,
    and it might contain @string references that will not allow compilation.

    Args:
      node: Node for which to remove Android labels.
    """
    if node.hasAttribute(self._ANDROID_LABEL):
      node.removeAttribute(self._ANDROID_LABEL)

  def _IsDuplicate(self, node_to_copy, node):
    """Is element a duplicate?"""
    for merger_node in self._merger_dom.getElementsByTagName(node_to_copy):
      if (merger_node.getAttribute(self._ANDROID_NAME) ==
          node.getAttribute(self._ANDROID_NAME)):
        return True
    return False

  def _RemoveIntentFilters(self, node):
    """Remove intent-filter in activity element.

    So there are no duplicate apps.

    Args:
      node: Node for which to remove intent filters.
    """
    intent_filters = node.getElementsByTagName(self._INTENT_FILTER)
    if intent_filters.length > 0:
      for sub_node in intent_filters:
        node.removeChild(sub_node)

  def _FindElementComment(self, node):
    """Find element's comment.

    Assumes that element's comment can be just above the element.
    Searches previous siblings and looks for the first non text element
    that is of a nodeType of comment node.

    Args:
      node: Node for which to find a comment.
    Returns:
      Elements's comment node, None if not found.
    """
    while node.previousSibling:
      node = node.previousSibling
      if node.nodeType is node.COMMENT_NODE:
        return node
      if node.nodeType is not node.TEXT_NODE:
        return None
    return None

  def _ReplaceArgumentPlaceholders(self, dom):
    """Replaces argument placeholders with their values.

    Modifies the attribute values of the input node.

    Args:
      dom: Xml node that should get placeholders replaced.
    """

    placeholders = {
        'packageName': self._merger_dom.getElementsByTagName(
            self._MANIFEST).item(0).getAttribute(self._PACKAGE),
    }

    for element in dom.getElementsByTagName('*'):
      for i in range(element.attributes.length):
        attr = element.attributes.item(i)
        attr.value = self._ReplaceArgumentHelper(placeholders, attr.value)

  def _ReplaceArgumentHelper(self, placeholders, attr):
    """Replaces argument placeholders within a single string.

    Args:
      placeholders: A dict mapping between placeholder names and their
                    replacement values.
      attr: A string in which to replace argument placeholders.

    Returns:
      A string with placeholders replaced, or the same string if no placeholders
      were found.
    """
    match_placeholder = '\\${([a-zA-Z]*)}'

    # Returns the replacement string for found matches.
    def PlaceholderReplacer(matchobj):
      found_placeholder = matchobj.group(1)
      if found_placeholder not in placeholders:
        raise UndefinedPlaceholderException(
            'Undefined placeholder when substituting arguments: '
            + found_placeholder)
      return placeholders[found_placeholder]

    attr = re.sub(match_placeholder, PlaceholderReplacer, attr)

    return attr

  def _SortAliases(self):
    applications = self._merger_dom.getElementsByTagName('application')
    if not applications:
      return
    for alias in applications[0].getElementsByTagName('activity-alias'):
      comment_node = self._FindElementComment(alias)
      while comment_node is not None:
        applications[0].appendChild(comment_node)
        comment_node = self._FindElementComment(alias)
      applications[0].appendChild(alias)

  def _FindMergerParent(self, tag_to_copy, destination_tag_name, mergee_dom):
    """Finds merger parent node, or appends mergee equivalent node if none."""
    # Merger parent element to which to add merged elements.
    if self._merger_dom.getElementsByTagName(destination_tag_name):
      return self._merger_dom.getElementsByTagName(destination_tag_name)[0]
    else:
      mergee_element = mergee_dom.getElementsByTagName(destination_tag_name)[0]
      # find the parent
      parents = self._merger_dom.getElementsByTagName(
          mergee_element.parentNode.tagName)
      if not parents:
        raise MalformedManifestException(
            'Malformed manifest has tag %s but no parent tag %s',
            (tag_to_copy, destination_tag_name))
      # append the mergee child as the first child.
      return parents[0].insertBefore(mergee_element, parents[0].firstChild)

  def Merge(self):
    """Takes two manifests, and merges them together to produce a third."""
    self._RemoveFromMerger()
    self._ExpandPackageName(self._merger_dom)

    for dom, filename in self._mergees:
      mergee_dom = xml.dom.minidom.parseString(dom)
      self._ReplaceArgumentPlaceholders(mergee_dom)
      self._ExpandPackageName(mergee_dom)
      self._ApplyExcludePermissions(mergee_dom)

      for destination, values in sorted(
          self._NODES_TO_COPY_FROM_MERGEE.iteritems()):
        for node_to_copy in values:
          for node in mergee_dom.getElementsByTagName(node_to_copy):
            if self._IsDuplicate(node_to_copy, node):
              continue

            merger_parent = self._FindMergerParent(node_to_copy,
                                                   destination,
                                                   mergee_dom)

            # Append the merge comment.
            merger_parent.appendChild(
                self._merger_dom.createComment(' Merged from file: %s ' %
                                               filename))

            # Append mergee's comment, if present.
            comment_node = self._FindElementComment(node)
            if comment_node:
              merger_parent.appendChild(comment_node)

            # Append element from mergee to merger.
            merger_parent.appendChild(node)

    # Insert top level comment about the merge.
    top_comment = (
        ' *** WARNING *** DO NOT EDIT! THIS IS GENERATED MANIFEST BY '
        'MERGE_MANIFEST TOOL.\n'
        '  Merger manifest:\n    %s\n' % self._merger[1] +
        '  Mergee manifests:\n%s' % '\n'.join(
            ['    %s' % mergee[1] for mergee in self._mergees]) +
        '\n  ')
    manifest_element = self._merger_dom.getElementsByTagName('manifest')[0]
    manifest_element.insertBefore(self._merger_dom.createComment(top_comment),
                                  manifest_element.firstChild)

    self._SortAliases()
    return self._merger_dom.toprettyxml(indent='  ')


def _ReadFiles(files):
  results = []
  for file_name in files:
    results.append(_ReadFile(file_name))
  return results


def _ReadFile(file_name):
  with open(file_name, 'r') as my_file:
    return (my_file.read(), file_name,)


def _ValidateAndWarnPermissions(exclude_permissions):
  unknown_permissions = (
      set(exclude_permissions)
      - set([EXCLUDE_ALL_ARG])
      - android_permissions.PERMISSIONS)
  return '\n'.join([
      'WARNING:\n\t Specified permission "%s" is not a standard permission. '
      'Is it a typo?' % perm for perm in unknown_permissions])


def main():
  if not FLAGS.merger:
    raise RuntimeError('Missing merger value.\n' + USAGE)
  if len(FLAGS.mergee) < 1:
    raise RuntimeError('Missing mergee value.\n' + USAGE)
  if not FLAGS.output:
    raise RuntimeError('Missing output value.\n' + USAGE)
  if FLAGS.exclude_permission:
    warning = _ValidateAndWarnPermissions(FLAGS.exclude_permission)
    if warning:
      print warning

  merged_manifests = MergeManifests(_ReadFile(FLAGS.merger),
                                    _ReadFiles(FLAGS.mergee),
                                    FLAGS.exclude_permission
                                   ).Merge()

  with open(FLAGS.output, 'w') as out_file:
    for line in merged_manifests.split('\n'):
      if not line.strip():
        continue
      out_file.write(line + '\n')

if __name__ == '__main__':
  FLAGS(sys.argv)
  main()
